21 ROS2 数据 Web 端实时展示
ROS2 数据 Web 端实时展示
关联:索引
要解决的问题
- Web 端已经能订阅 ROS2 话题了,为什么“能收到”不等于“能展示”:数据结构、频率、空值与异常怎么处理?
- 视觉检测结果往往是“数组 + 嵌套对象”,前端如何做解析、校验、格式化,再渲染成列表/表格?
- 设备状态(在线/离线/故障)如何做统一状态色与提示?断线/延迟怎么判定?
- 参数数据(例如 PID、阈值、开关量)是“配置快照”,如何用表格展示并支持实时更新(只刷新变化项)?
- 频率较高的数据推送会导致页面卡顿:如何做限流、环形缓冲、列表长度控制与图表刷新策略?
章节内容(本讲核心):
- 视觉检测结果、设备状态、参数数据在网页实时展示(卡片/列表/表格)
- 数据解析与格式化(安全解析、字段校验、时间戳与单位、人类可读映射)
- 实时图表 / 列表更新(环形缓冲、限制列表长度、刷新节奏控制)
与前置知识衔接(避免重复):
- 已学:WebSocket 生命周期事件、统一 JSON 消息、解析容错(见 WebSocket 系列)
- 已学:rosbridge 原理、环境部署与 9090 端口验证(见 rosbridge )
- 已学:Web 端连接 rosbridge 与话题订阅、多话题订阅封装(见“Web 端连接 ROS2 与话题订阅”)
- 已学(项目起点):上节课项目
07_rosbridge_topic_subscriber已包含RosbridgeClient(connect / waitForOpen / subscribe / unsubscribe / disconnect)与RosbridgeWorkshop.vue的多话题订阅示例 - 本讲不重复:如何启动 rosbridge、subscribe/unsubscribe 协议字段解释(默认已掌握)
项目工坊:开发 ROS2 数据实时监控面板
- 面板内容:设备状态卡片 + 视觉检测结果列表 + 参数表格 + 一个实时曲线(示例:电池电量/温度/检测数量)
学生任务:
- 实现数据解析(安全解析 + 字段校验 + 格式化)
- 实现渲染(卡片/列表/表格/图表)
- 实现自动更新(实时推送驱动更新,限制数据量,组件卸载自动清理)
大模型任务:
- AI 生成“某个具体话题”的解析与格式化代码(Topic 结构 → TS 类型 → Parser → ViewModel)
- AI 生成“实时面板页面”的渲染代码(列表/表格/图表更新)
作业:
说明:
- 本讲提供“用
std_msgs/msg/String携带 JSON 字符串”的方式来模拟三类数据,保证每个人都能跑通展示。 - 若你已有真实话题(自定义 msg / vision_msgs 等),只需要把本讲的 Parser 换成对应结构即可,展示层完全复用。
1) 视觉检测结果(String 携带 JSON)
建议话题名:/vision/detections_json,消息类型:std_msgs/msg/String
示例 payload(这是 String 的 data 字段内部 JSON):
{
"frame_id": "camera_front",
"stamp_ms": 1760000000123,
"detections": [
{ "label": "person", "score": 0.92, "bbox": { "x": 120, "y": 40, "w": 80, "h": 160 } },
{ "label": "forklift", "score": 0.81, "bbox": { "x": 260, "y": 90, "w": 140, "h": 110 } }
]
}
frame_id:来自哪个相机/坐标系(用于面板展示来源)stamp_ms:毫秒时间戳(用于“最新更新时间”与曲线横轴)detections:检测数组(label/score/bbox)
2) 设备状态(String 携带 JSON)
建议话题名:/device/state_json,消息类型:std_msgs/msg/String
{
"device_id": "agv-01",
"online": true,
"mode": "AUTO",
"battery": 76.3,
"temperature": 41.2,
"alarm_level": "WARN",
"stamp_ms": 1760000000456
}
online:用于在线/离线判断(离线时应提示“数据超时/连接断开”)mode/alarm_level:用于状态色与标签映射battery/temperature:用于卡片与实时曲线
3) 参数数据(String 携带 JSON)
建议话题名:/device/params_json,消息类型:std_msgs/msg/String
{
"stamp_ms": 1760000000789,
"params": {
"max_speed": 1.5,
"kp": 0.8,
"ki": 0.02,
"kd": 0.01,
"enable_vision_stop": true
}
}
params:键值对(数值/布尔/字符串都可能出现)- 表格展示建议:三列(key / value / update_time)
4) ROS2 侧发布命令(用于模拟数据流)
以下命令用于在 ROS2 侧持续发布“JSON 字符串”,让前端订阅到数据。
说明:命令参数最后一段是 std_msgs/msg/String 的 YAML 入参;本给出的写法在 Bash 与 PowerShell 下都可用(如果你在 zsh 等环境遇到引号问题,优先把外层单引号保留,并减少不必要的嵌套引号)。
发布视觉检测模拟数据(1Hz):
# 发布 /vision/detections_json(1Hz),消息类型为 std_msgs/msg/String
# 注意:外层单引号包住 YAML;data 内层双引号包住 JSON;JSON 内的 \" 只是 YAML 转义,进入 data 后不会保留反斜杠
ros2 topic pub -r 1 /vision/detections_json std_msgs/msg/String '{data: "{\"frame_id\":\"camera_front\",\"stamp_ms\":1775784807437,\"detections\":[{\"label\":\"person\",\"score\":0.92,\"bbox\":{\"x\":120,\"y\":40,\"w\":80,\"h\":160}}]}"}'
/vision/detections_json:话题名,要与 Web 端订阅一致std_msgs/msg/String:消息类型{data: "...json..."}:String 消息体(YAML),核心在data中放入 JSON 字符串- 注意:推荐用“外层单引号包 YAML + data 内层双引号包 JSON”的写法。
\"是给 YAML 双引号字符串用的转义,最终进入data的内容不会包含反斜杠
发布设备状态模拟数据(5Hz):
# 发布 /device/state_json(5Hz),用于驱动状态卡片与实时曲线(battery/temperature)
# stamp_ms 推荐为 epoch(ms);如果你传的是秒/微秒/纳秒,前端需要做单位归一化
ros2 topic pub -r 5 /device/state_json std_msgs/msg/String '{data: "{\"device_id\":\"agv-01\",\"online\":true,\"mode\":\"AUTO\",\"battery\":76.3,\"temperature\":41.2,\"alarm_level\":\"WARN\",\"stamp_ms\":1775784807437}"}'
- 建议让设备状态稍微高频一点(例如 2–10Hz),便于观察卡片与曲线的实时更新
发布参数快照(0.5Hz):
# 发布 /device/params_json(0.5Hz),模拟“参数快照”(通常低频或按变更发布)
# params 允许混合类型:number / boolean / string / object(前端展示层需要统一格式化)
ros2 topic pub -r 0.5 /device/params_json std_msgs/msg/String '{data: "{\"stamp_ms\":1775784807437,\"params\":{\"max_speed\":1.5,\"kp\":0.8,\"ki\":0.02,\"kd\":0.01,\"enable_vision_stop\":true}}"}'
- 参数通常不需要高频(低频/按变更发布更合理)
工程准备:从 07 复制出 08 项目(本讲代码落位)
本讲默认你已经完成上节课工程 07_rosbridge_topic_subscriber。为了避免“边改边坏”,本讲统一在一个新工程里做实时面板开发:
- 上节课工程:
07_rosbridge_topic_subscriber/(保持不动) - 本节课工程:
08_ros2_realtime_dashboard/(在 07 的基础上复制得到)
1) 复制工程(PowerShell)
在 0410 目录执行:
# 从上一讲工程复制出 08 工程(避免“边改边坏”,也便于课堂回滚)
Copy-Item .\07_rosbridge_topic_subscriber .\08_ros2_realtime_dashboard -Recurse
# 进入 08 工程目录
cd .\08_ros2_realtime_dashboard
# 安装依赖(首次运行必须)
npm install
# 启动开发服务器(浏览器打开终端提示的地址)
npm run dev
Copy-Item ... -Recurse:完整复制上一讲工程到新目录,确保 07 工程不被修改cd ...:进入 08 工程目录npm install:安装依赖(如果你之前没装过,或复制时没有 node_modules,就必须执行)npm run dev:启动 Vite 开发服务器,浏览器打开终端输出的地址
Windows(PowerShell)常见提示:如果遇到 “禁止运行脚本 npm.ps1”,优先用 npm.cmd 执行同等命令:
# PowerShell 执行策略可能禁止运行 npm.ps1:用 npm.cmd 可以绕过该限制
npm.cmd install
npm.cmd run dev
- 原因:PowerShell 的执行策略可能禁止运行
npm.ps1;npm.cmd不受该限制
2) 本讲新增/修改的文件(以 08 工程为准)
建议最终结构如下(相对路径):
08_ros2_realtime_dashboard/
src/
App.vue
components/
Ros2RealtimeDashboard.vue
LineChartECharts.vue
utils/
rosbridge.ts
ros-parse.ts
ros-vm.ts
ring-buffer.ts
utils/rosbridge.ts:沿用上节课RosbridgeClient,并可在 08 工程内扩展 subscribe 参数(节流/队列),不影响 07utils/ros-parse.ts:把不同 topic 的unknown msg安全解析为可信结构(Parser 层)utils/ros-vm.ts:定义 Payload 与 ViewModel(VM)类型utils/ring-buffer.ts:固定容量时间序列缓存(为实时曲线服务)components/LineChartECharts.vue:ECharts 折线图组件(time 轴 + 合帧更新 + 自适应)components/Ros2RealtimeDashboard.vue:实时面板页面(连接/订阅/解析/渲染/自动更新)
推荐分层(从内到外):
- 原始推送(rosbridge publish):
{ op, topic, msg } - Topic 路由层:按
topic找到对应 Parser - Parser(解析 + 校验):把
unknown转成“可信结构” - ViewModel(格式化):把“可信结构”转成“展示友好结构”
- 组件渲染:只读 ViewModel,不直接访问
unknown的深层字段
这样做的直接收益:
- 话题数量变多时,不会在组件里堆一堆 if/else
- 更容易做单元测试/自测(Parser 输入输出明确)
- 页面不容易因为某条异常消息导致崩溃
以下代码建议放在 src/utils/ros-vm.ts(命名仅作建议)。
// src/utils/ros-vm.ts
// 目标:把“业务层数据(Payload)”与“展示层数据(VM)”分开,组件只拿 VM 去渲染
// bbox:可选,因为不同视觉节点/模型可能只给 label/score,不给 bbox
export type VisionBBox = {
x: number
y: number
w: number
h: number
}
// 单个检测结果(score 建议在 0~1 之间,展示层可转成百分比)
export type VisionDetection = {
label: string
score: number
bbox?: VisionBBox
}
// 一帧检测结果快照(stamp_ms 用于“更新时间/延迟/曲线横轴”)
export type VisionDetectionsPayload = {
frame_id: string
stamp_ms: number
detections: VisionDetection[]
}
// 告警等级:用联合类型约束字段,避免“随便一个字符串”导致 UI 状态不可控
export type DeviceAlarmLevel = 'OK' | 'WARN' | 'ERROR'
// 设备状态快照(字段较固定,适合做强校验)
export type DeviceStatePayload = {
device_id: string
online: boolean
mode: string
battery: number
temperature: number
alarm_level: DeviceAlarmLevel
stamp_ms: number
}
// 参数快照:params 是 key->value;value 类型不固定,所以用 unknown 承接
export type ParamsPayload = {
stamp_ms: number
params: Record<string, unknown>
}
// 视觉检测的“表格行”VM:全部转成易渲染的字符串
export type VisionDetectionRowVM = {
label: string
scoreText: string
bboxText: string
}
// 设备状态卡片 VM:把数值/状态码格式化为人类可读文本
export type DeviceStateVM = {
deviceId: string
onlineText: string
alarmText: string
modeText: string
batteryText: string
temperatureText: string
// delayText:数据延迟(只有当 stamp_ms 可信时才展示;不可信可显示 “—”)
delayText: string
updatedAtText: string
}
// 参数表格行 VM:valueText 统一为字符串,避免模板里大量 typeof 判断
export type ParamRowVM = {
key: string
valueText: string
updatedAtText: string
}
Payload:表示“业务层可信结构”,字段尽量与 JSON 结构对齐VM:表示“展示层结构”,字段尽量是字符串或可直接渲染的简单结构Record<string, unknown>:参数表的 value 类型不确定,先用 unknown 承接,再在格式化时做类型收敛
以下代码建议放在 src/utils/ros-parse.ts。
import type {
DeviceAlarmLevel,
DeviceStatePayload,
ParamsPayload,
VisionDetectionsPayload
} from './ros-vm'
// src/utils/ros-parse.ts
// 目标:把 rosbridge 推过来的 unknown msg 安全解析为 Payload
// 注意:本讲的三个话题都是 std_msgs/msg/String,真正的 JSON 在 msg.data 里
export function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text)
} catch {
return null
}
}
// unknown -> number(并保证是有限数),用于过滤 NaN/Infinity/字符串数字等脏数据
function toNumber(x: unknown): number | null {
return typeof x === 'number' && Number.isFinite(x) ? x : null
}
// unknown -> string
function toString(x: unknown): string | null {
return typeof x === 'string' ? x : null
}
// unknown -> boolean
function toBoolean(x: unknown): boolean | null {
return typeof x === 'boolean' ? x : null
}
// unknown -> DeviceAlarmLevel(不在枚举内就判为无效)
function toAlarmLevel(x: unknown): DeviceAlarmLevel | null {
return x === 'OK' || x === 'WARN' || x === 'ERROR' ? x : null
}
// 从 rosbridge 的 msg 中提取 std_msgs/msg/String 的 data 字段
function parseStdStringMsgData(msg: unknown): string | null {
if (!msg || typeof msg !== 'object' || !('data' in msg)) return null
const m = msg as { data?: unknown }
return toString(m.data)
}
// 视觉检测:msg.data(JSON字符串) -> VisionDetectionsPayload
export function parseVisionDetectionsJsonStringMsg(msg: unknown): VisionDetectionsPayload | null {
const text = parseStdStringMsgData(msg)
if (!text) return null
const data = safeJsonParse(text)
if (!data || typeof data !== 'object') return null
// 只取我们关心的字段;未知字段忽略,保证兼容后续扩展
const d = data as {
frame_id?: unknown
stamp_ms?: unknown
detections?: unknown
}
const frame_id = toString(d.frame_id)
const stamp_ms = toNumber(d.stamp_ms)
const detectionsRaw = Array.isArray(d.detections) ? d.detections : null
if (!frame_id || stamp_ms === null || !detectionsRaw) return null
const detections = detectionsRaw
.map((item) => {
if (!item || typeof item !== 'object') return null
const it = item as {
label?: unknown
score?: unknown
bbox?: unknown
}
const label = toString(it.label)
const score = toNumber(it.score)
if (!label || score === null) return null
let bbox: { x: number; y: number; w: number; h: number } | undefined
if (it.bbox && typeof it.bbox === 'object') {
const b = it.bbox as { x?: unknown; y?: unknown; w?: unknown; h?: unknown }
const x = toNumber(b.x)
const y = toNumber(b.y)
const w = toNumber(b.w)
const h = toNumber(b.h)
if (x !== null && y !== null && w !== null && h !== null) bbox = { x, y, w, h }
}
return { label, score, bbox }
})
.filter((x): x is NonNullable<typeof x> => x !== null)
return { frame_id, stamp_ms, detections }
}
// 设备状态:msg.data(JSON字符串) -> DeviceStatePayload
export function parseDeviceStateJsonStringMsg(msg: unknown): DeviceStatePayload | null {
const text = parseStdStringMsgData(msg)
if (!text) return null
const data = safeJsonParse(text)
if (!data || typeof data !== 'object') return null
const d = data as {
device_id?: unknown
online?: unknown
mode?: unknown
battery?: unknown
temperature?: unknown
alarm_level?: unknown
stamp_ms?: unknown
}
const device_id = toString(d.device_id)
const online = toBoolean(d.online)
const mode = toString(d.mode)
const battery = toNumber(d.battery)
const temperature = toNumber(d.temperature)
const alarm_level = toAlarmLevel(d.alarm_level)
const stamp_ms = toNumber(d.stamp_ms)
if (
!device_id ||
online === null ||
!mode ||
battery === null ||
temperature === null ||
!alarm_level ||
stamp_ms === null
) {
return null
}
return { device_id, online, mode, battery, temperature, alarm_level, stamp_ms }
}
// 参数快照:msg.data(JSON字符串) -> ParamsPayload(params 允许混合类型)
export function parseParamsJsonStringMsg(msg: unknown): ParamsPayload | null {
const text = parseStdStringMsgData(msg)
if (!text) return null
const data = safeJsonParse(text)
if (!data || typeof data !== 'object') return null
const d = data as { stamp_ms?: unknown; params?: unknown }
const stamp_ms = toNumber(d.stamp_ms)
const params = d.params && typeof d.params === 'object' ? (d.params as Record<string, unknown>) : null
if (stamp_ms === null || !params) return null
return { stamp_ms, params }
}
safeJsonParse:任何解析失败都返回null,保证组件不会因为异常数据崩溃parseStdStringMsgData:只处理std_msgs/msg/String,把msg.data安全收敛为 stringparse*JsonStringMsg:这里的msg是 rosbridge 的msg字段(对 String 类型,核心是msg.data)toNumber/toString/toBoolean:把 unknown 收敛成基本类型;不通过就返回nulldetections.map(...).filter(...):保证最终数组里不会夹杂null,避免渲染时出现类型不一致
下面示例展示“一个面板组件”如何接入三类 Parser 并实时展示。
说明:
- 如果你已经在上一讲完成了
RosbridgeClient封装,可以直接复用连接与订阅部分。 - 为了把重点放在“解析与展示”,这里给出一个可粘贴的“单组件最小实现”:连接 +(复用上一讲封装的)订阅 + 解析 + 渲染。
建议文件:src/components/Ros2RealtimeDashboard.vue(08 工程)
<template>
<div class="wrap">
<h2>ROS2 数据实时展示</h2>
<!-- 连接区:用输入框配置 rosbridge 地址,按钮触发 connect/disconnect -->
<div class="row">
<label class="label" for="url">rosbridge URL</label>
<input id="url" v-model="url" class="input" type="text" />
<button class="btn" type="button" :disabled="status === 'OPEN' || status === 'CONNECTING'" @click="connect">
连接
</button>
<button class="btn" type="button" :disabled="status !== 'OPEN'" @click="disconnect">断开</button>
<span class="status">状态:{{ status }}</span>
</div>
<div class="grid">
<!-- 状态卡片:展示 /device/state_json 解析后的 VM -->
<section class="card">
<h3>设备状态</h3>
<div class="kv"><span class="k">设备</span><span class="v">{{ deviceVm?.deviceId ?? '—' }}</span></div>
<div class="kv"><span class="k">在线</span><span class="v">{{ deviceVm?.onlineText ?? '—' }}</span></div>
<div class="kv"><span class="k">模式</span><span class="v">{{ deviceVm?.modeText ?? '—' }}</span></div>
<div class="kv"><span class="k">告警</span><span class="v">{{ deviceVm?.alarmText ?? '—' }}</span></div>
<div class="kv"><span class="k">电量</span><span class="v">{{ deviceVm?.batteryText ?? '—' }}</span></div>
<div class="kv"><span class="k">温度</span><span class="v">{{ deviceVm?.temperatureText ?? '—' }}</span></div>
<div class="kv"><span class="k">延迟</span><span class="v">{{ deviceVm?.delayText ?? '—' }}</span></div>
<div class="kv"><span class="k">更新时间</span><span class="v">{{ deviceVm?.updatedAtText ?? '—' }}</span></div>
</section>
<!-- 检测列表:展示 /vision/detections_json 的最新一帧 -->
<section class="card">
<h3>视觉检测(最新一帧)</h3>
<div class="muted">frame: {{ visionFrameId ?? '—' }} | time: {{ visionUpdatedAtText ?? '—' }}</div>
<table class="table">
<thead>
<tr>
<th>label</th>
<th>score</th>
<th>bbox</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in visionRows" :key="idx">
<td>{{ row.label }}</td>
<td>{{ row.scoreText }}</td>
<td class="muted">{{ row.bboxText }}</td>
</tr>
<tr v-if="visionRows.length === 0">
<td class="muted" colspan="3">(暂无数据)</td>
</tr>
</tbody>
</table>
</section>
<!-- 参数表格:展示 /device/params_json 的 key/value 快照 -->
<section class="card">
<h3>参数快照(最新)</h3>
<div class="muted">time: {{ paramsUpdatedAtText ?? '—' }}</div>
<table class="table">
<thead>
<tr>
<th>key</th>
<th>value</th>
<th>updated</th>
</tr>
</thead>
<tbody>
<tr v-for="row in paramRows" :key="row.key">
<td>{{ row.key }}</td>
<td>{{ row.valueText }}</td>
<td class="muted">{{ row.updatedAtText }}</td>
</tr>
<tr v-if="paramRows.length === 0">
<td class="muted" colspan="3">(暂无数据)</td>
</tr>
</tbody>
</table>
</section>
</div>
<div v-if="lastError" class="error">错误:{{ lastError }}</div>
</div>
</template>
<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'
import { RosbridgeClient } from '../utils/rosbridge'
import { parseDeviceStateJsonStringMsg, parseParamsJsonStringMsg, parseVisionDetectionsJsonStringMsg } from '../utils/ros-parse'
import type { DeviceStateVM, ParamRowVM, VisionDetectionRowVM } from '../utils/ros-vm'
// 连接状态:用来控制按钮 disabled 与页面提示
type Status = 'IDLE' | 'CONNECTING' | 'OPEN' | 'CLOSED'
// rosbridge ws 地址(默认本机 9090)
const url = ref('ws://localhost:9090')
const status = ref<Status>('IDLE')
const lastError = ref('')
// RosbridgeClient:负责把 publish 消息按 topic 路由到 handler(组件只写 subscribe)
let client: RosbridgeClient | null = null
// /vision/detections_json:frame_id / stamp_ms / detections[](转成表格行 VM)
const visionFrameId = ref<string | null>(null)
const visionUpdatedAt = ref<number | null>(null)
const visionRows = ref<VisionDetectionRowVM[]>([])
// /device/state_json:设备状态(转成卡片 VM)
const deviceVm = ref<DeviceStateVM | null>(null)
// /device/params_json:参数快照(转成表格行 VM)
const paramsUpdatedAt = ref<number | null>(null)
const paramRows = ref<ParamRowVM[]>([])
// 把毫秒时间戳格式化为 HH:mm:ss.SSS,用于“最近更新时间”
const visionUpdatedAtText = computed(() => (visionUpdatedAt.value ? formatTime(visionUpdatedAt.value) : null))
const paramsUpdatedAtText = computed(() => (paramsUpdatedAt.value ? formatTime(paramsUpdatedAt.value) : null))
function formatTime(tsMs: number): string {
const d = new Date(tsMs)
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const ss = String(d.getSeconds()).padStart(2, '0')
const ms = String(d.getMilliseconds()).padStart(3, '0')
return `${hh}:${mm}:${ss}.${ms}`
}
function formatPercent(x: number): string {
const v = Math.max(0, Math.min(1, x))
return `${(v * 100).toFixed(1)}%`
}
function formatAlarmText(level: 'OK' | 'WARN' | 'ERROR'): string {
return level === 'OK' ? '正常' : level === 'WARN' ? '预警' : '故障'
}
function formatAny(v: unknown): string {
if (typeof v === 'string') return v
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'NaN'
if (typeof v === 'boolean') return v ? 'true' : 'false'
if (v === null) return 'null'
if (v === undefined) return 'undefined'
try {
return JSON.stringify(v)
} catch {
return String(v)
}
}
function keepLatestN<T>(arr: T[], n: number): T[] {
if (arr.length <= n) return arr
return arr.slice(arr.length - n)
}
// connect() 做两件事:
// 1) 建立 WebSocket 并等待 OPEN(waitForOpen)
// 2) 注册三个 topic 的 handler:每个 handler 内做 parse -> vm -> 写入响应式状态
async function connect(): Promise<void> {
lastError.value = ''
status.value = 'CONNECTING'
client = new RosbridgeClient(url.value)
client.connect()
try {
await client.waitForOpen(5000)
status.value = 'OPEN'
} catch (e) {
status.value = 'CLOSED'
lastError.value = e instanceof Error ? e.message : 'connect failed'
client?.disconnect()
client = null
return
}
client.subscribe('/vision/detections_json', 'std_msgs/msg/String', (msg) => {
// msg 是 rosbridge 的 msg 字段(unknown),对 std_msgs/msg/String 来说就是 { data: "<json string>" }
const p = parseVisionDetectionsJsonStringMsg(msg)
if (!p) return
visionFrameId.value = p.frame_id
visionUpdatedAt.value = p.stamp_ms
const rows = p.detections.map((d) => ({
label: d.label,
scoreText: formatPercent(d.score),
bboxText: d.bbox ? `${d.bbox.x},${d.bbox.y},${d.bbox.w},${d.bbox.h}` : '—'
}))
visionRows.value = keepLatestN(rows, 30)
})
client.subscribe('/device/state_json', 'std_msgs/msg/String', (msg) => {
const p = parseDeviceStateJsonStringMsg(msg)
if (!p) return
// 用“本机时间 - 上游时间戳”计算延迟(后续可升级为 epoch(ms) 归一化与超时判定)
const delayMs = Math.max(0, Date.now() - p.stamp_ms)
deviceVm.value = {
deviceId: p.device_id,
onlineText: p.online ? '在线' : '离线',
alarmText: formatAlarmText(p.alarm_level),
modeText: p.mode,
batteryText: `${p.battery.toFixed(1)}%`,
temperatureText: `${p.temperature.toFixed(1)}℃`,
delayText: `${delayMs}ms`,
updatedAtText: formatTime(p.stamp_ms)
}
})
client.subscribe('/device/params_json', 'std_msgs/msg/String', (msg) => {
const p = parseParamsJsonStringMsg(msg)
if (!p) return
paramsUpdatedAt.value = p.stamp_ms
// 参数表格:按 key 排序,保证每次刷新行顺序稳定,减少视觉抖动
paramRows.value = Object.entries(p.params)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => ({
key,
valueText: formatAny(value),
updatedAtText: formatTime(p.stamp_ms)
}))
})
}
// 断开连接:释放 WebSocket + 清掉 client 引用(避免热更新重复连接)
function disconnect(): void {
client?.disconnect()
client = null
status.value = 'CLOSED'
}
// 组件卸载时自动断开,避免切换页面/热更新造成“重复订阅、重复数据”
onUnmounted(() => {
disconnect()
})
</script>
<style scoped>
.wrap {
max-width: 1100px;
margin: 0 auto;
padding: 16px;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, 'Apple Color Emoji', 'Segoe UI Emoji';
}
.row {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0;
}
.label {
min-width: 110px;
color: #555;
}
.input {
flex: 1;
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 8px;
}
.btn {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
cursor: pointer;
}
.status {
color: #555;
}
.grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.card {
border: 1px solid #eee;
border-radius: 12px;
padding: 12px;
background: #fff;
}
.kv {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 4px 0;
}
.k {
color: #666;
}
.v {
color: #111;
}
.muted {
color: #888;
font-size: 12px;
}
.table {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
}
.table th,
.table td {
border-top: 1px solid #f0f0f0;
padding: 6px 8px;
text-align: left;
vertical-align: top;
}
.error {
margin-top: 12px;
padding: 10px 12px;
background: #fff1f1;
border: 1px solid #ffd0d0;
color: #b00020;
border-radius: 10px;
}
</style>
RosbridgeClient:复用上节课封装,把publish envelope的解析与 topic 路由封装到subscribe(topic, handler)内- 三类
subscribe:每个 topic 对应一个 Parser,解析成功后再映射成 VM keepLatestN(rows, 30):限制视觉检测列表最大行数,避免 DOM 无限制增长delayText:用Date.now() - stamp_ms显示数据延迟,帮助判断“掉线/卡顿/积压”
六、练习(至少 2 题)
- 把设备状态卡片增加一个“数据延迟”字段:
Date.now() - stamp_ms,并把延迟超过 2000ms 的状态显示为“可能掉线/延迟高”。 - 把视觉检测列表增加“只显示 score > 0.5”的过滤,并在页面上显示“过滤前/过滤后数量”。
八、学生任务(提交物与标准)
-
提交物 1:三类 Parser 源码(或至少两类 + 一类你自定义的真实话题)
-
提交物 2:面板页面截图或录屏(能看到实时刷新)
-
标准:老师把你的 ws 地址与话题名换成自己的,仍能复用你的“解析层 + 展示层结构”
-
约束 1:列表无限增长会导致渲染与内存持续变差(必须限长)
-
约束 2:图表每条消息都重绘会卡(需要控制重绘节奏)
-
约束 3:频率高的话题应该在订阅侧做节流(rosbridge 支持
throttle_rate)
对高频 topic(例如 30Hz 的状态/传感器),建议在 subscribe 中加节流参数:
{
"op": "subscribe",
"topic": "/device/state_json",
"type": "std_msgs/msg/String",
"id": "sub-device",
"throttle_rate": 200,
"queue_length": 1
}
throttle_rate:单位毫秒。200表示最多每 200ms 推一条(约 5Hz)queue_length:队列长度。1表示只保留最新,避免积压导致“越卡越延迟”
如果你复用了上一讲的 RosbridgeClient,推荐在 08 工程里把 subscribe 扩展为支持可选参数(不影响 07 工程),这样组件侧写法会更工程化:
client.subscribe(
'/device/state_json',
'std_msgs/msg/String',
(msg) => {
// handler:只处理“这一条 topic 的 msg”
// 推荐流程:parse(unknown) -> payload(可信结构) -> VM(展示结构) -> 写入响应式状态
// 这样组件模板里就不会出现一堆 msg.xxx?.yyy 的脆弱访问
},
{ throttle_rate: 200, queue_length: 1 }
)
- 最关键:节流/队列是“订阅请求”里的字段,不是前端
setInterval的替代品 - 建议把节流策略放在“订阅层”,把渲染节奏放在“展示层”(两者职责不同)
建议新建 src/utils/ring-buffer.ts,用于保存电量/温度等曲线数据。
export type Point = { t: number; v: number }
// src/utils/ring-buffer.ts
// 目标:固定容量缓存,保存“最近 N 个点”,避免数组无限增长导致卡顿
export class RingBuffer {
private readonly cap: number
private data: Point[]
private head = 0
private size = 0
constructor(capacity: number) {
// capacity 强制为 >=1 的整数
this.cap = Math.max(1, Math.floor(capacity))
this.data = new Array<Point>(this.cap)
}
push(p: Point): void {
// 写入当前位置(覆盖最旧点)
this.data[this.head] = p
this.head = (this.head + 1) % this.cap
this.size = Math.min(this.size + 1, this.cap)
}
toArray(): Point[] {
if (this.size === 0) return []
// 从最旧元素开始按时间顺序展开
const start = (this.head - this.size + this.cap) % this.cap
const out: Point[] = []
for (let i = 0; i < this.size; i += 1) {
out.push(this.data[(start + i) % this.cap])
}
return out
}
}
capacity:固定容量(例如只保留最近 120 个点)push:写入新点并覆盖最旧点,内存不会增长toArray:把环形结构按时间顺序展开,供图表渲染使用
说明:
- Element Plus 不自带图表组件;本讲使用 ECharts(工业项目常用),把“图表渲染”独立成一个组件。
- 数据结构仍沿用本讲的 time series(
{ t, v }[]),后续更换图表库只需要替换图表组件,不影响数据层。
1) 安装依赖(在 08 工程执行)
如果你在 PowerShell 遇到 npm.ps1 执行策略限制,优先用 npm.cmd:
# 在 08 工程安装 ECharts(只装主包即可)
cd .\08_ros2_realtime_dashboard
npm.cmd i echarts
echarts:ECharts 主库(本讲只用折线图,不额外安装封装库)
2) 新建图表组件:src/components/LineChartECharts.vue
<template>
<div ref="elRef" class="chart"></div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
type Point = { t: number; v: number }
const props = defineProps<{
points: Point[]
yMin?: number
yMax?: number
label?: string
}>()
const elRef = ref<HTMLDivElement | null>(null)
// chart:ECharts 实例(组件卸载时必须 dispose)
let chart: echarts.ECharts | null = null
// ro:容器大小变化监听(比 window resize 更准确,适配卡片布局变化)
let ro: ResizeObserver | null = null
// raf:合帧更新用的 requestAnimationFrame id
let raf = 0
function formatAxisTime(value: unknown): string {
const ts = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN
if (!Number.isFinite(ts)) return ''
const d = new Date(ts)
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const ss = String(d.getSeconds()).padStart(2, '0')
return `${hh}:${mm}:${ss}`
}
function buildOption(): echarts.EChartsOption {
const seriesData = props.points.map((p) => [p.t, p.v])
const shouldScale = props.yMin === undefined || props.yMax === undefined
return {
// 高频更新时建议关掉动画,避免抖动与掉帧
animation: false,
grid: { left: 40, right: 14, top: 30, bottom: 34 },
title: props.label
? { text: props.label, left: 10, top: 6, textStyle: { fontSize: 12, fontWeight: 'normal', color: '#666' } }
: undefined,
tooltip: { trigger: 'axis', axisPointer: { type: 'line' } },
xAxis: {
type: 'time',
// splitNumber 只是“建议分段数”,最终还会受容器宽度影响
splitNumber: 4,
axisLabel: { color: '#888', hideOverlap: true, margin: 10, formatter: formatAxisTime },
axisLine: { lineStyle: { color: '#eee' } }
},
yAxis: {
type: 'value',
min: props.yMin,
max: props.yMax,
scale: shouldScale,
axisLabel: { color: '#888' },
splitLine: { lineStyle: { color: '#f2f2f2' } },
axisLine: { lineStyle: { color: '#eee' } }
},
series: [{ type: 'line', showSymbol: false, data: seriesData, lineStyle: { width: 2, color: '#1677ff' } }]
}
}
function render(): void {
if (!chart) return
// notMerge: true 让每次 option 都以当前构建为准,避免残留;lazyUpdate: true 允许内部做延后渲染优化
chart.setOption(buildOption(), { notMerge: true, lazyUpdate: true })
}
function scheduleRender(): void {
if (raf) cancelAnimationFrame(raf)
// 合并短时间内多次 points 更新(例如 5Hz~几十 Hz 推送),最多每帧渲染一次
raf = requestAnimationFrame(() => render())
}
function onResize(): void {
chart?.resize()
}
watch(
() => props.points,
() => scheduleRender()
)
watch(
() => [props.yMin, props.yMax, props.label],
() => scheduleRender()
)
onMounted(() => {
const el = elRef.value
if (!el) return
// 初始化图表实例:容器必须已有尺寸,否则会出现 0x0 图表
chart = echarts.init(el, undefined, { renderer: 'canvas' })
scheduleRender()
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(() => {
chart?.resize()
})
ro.observe(el)
} else {
// 退化方案:只监听窗口 resize(容器内部布局变化可能捕捉不到)
window.addEventListener('resize', onResize)
}
})
onUnmounted(() => {
if (raf) cancelAnimationFrame(raf)
if (ro && elRef.value) ro.unobserve(elRef.value)
ro = null
window.removeEventListener('resize', onResize)
// 释放 ECharts 持有的 DOM/事件/内存
chart?.dispose()
chart = null
})
</script>
<style scoped>
.chart {
width: 100%;
height: 180px;
border-radius: 10px;
background: #fff;
}
</style>
echarts.init(el):在容器 DOM 上初始化图表实例;组件卸载时必须dispose(),避免内存泄漏xAxis.type = 'time':直接用时间戳作为横轴;series 数据为[t, v]数组axisLabel.formatter:把 time 轴标签缩短为HH:mm:ss,并配合hideOverlap避免高频数据时标签挤在一起setOption(..., { notMerge: true }):每次用最新数据渲染,避免 option 合并导致残留ResizeObserver:容器尺寸变化时自动resize();没有该 API 时退化到 window resizerequestAnimationFrame:把多次数据更新合并到下一帧,避免频繁 setOption 带来的卡顿
核心目标:
- 设备状态到来时:更新卡片,同时把电量/温度 push 进 ring buffer
- 检测列表到来时:只保留最新 N 行(例如 30 行)
- 参数快照到来时:只更新变化项(本讲给出一个易实现版本:直接重建行;扩展题再做 diff)
import { ref } from 'vue'
import { RingBuffer } from '../utils/ring-buffer'
const batterySeries = new RingBuffer(120)
const batteryPoints = ref<{ t: number; v: number }[]>([])
// 目的:把上游 stamp_ms 统一成“epoch(ms)”:
// - raw 若本身就是 ms:直接通过
// - raw 若是秒:raw*1000
// - raw 若是微秒:raw/1000
// - raw 若是纳秒:raw/1_000_000
// 如果都不像一个合理的日期,就用 receiveMs 回退(保证曲线始终能画)
function resolveStampMs(raw: number, receiveMs: number): number {
const minMs = Date.UTC(2000, 0, 1)
const maxMs = Date.UTC(2100, 0, 1)
const candidates = [raw, raw * 1000, raw / 1000, raw / 1_000_000]
for (const c of candidates) {
if (Number.isFinite(c) && c >= minMs && c <= maxMs) return Math.floor(c)
}
return receiveMs
}
// lastT:记录上一点的时间戳,保证时间严格递增(ECharts time 轴更稳定)
let lastT: number | null = null
function onDeviceState(stamp_ms: number, battery: number): void {
// receiveMs:前端收到消息的时刻(用于回退与乱序保护)
const receiveMs = Date.now()
let t = resolveStampMs(stamp_ms, receiveMs)
// 如果点乱序/重复:强制让 t 递增,否则曲线可能出现“来回跳/不连线/看起来不更新”
if (lastT !== null && t <= lastT) t = Math.max(receiveMs, lastT + 1)
lastT = t
batterySeries.push({ t, v: battery })
batteryPoints.value = batterySeries.toArray()
}
function keepLatestN<T>(arr: T[], n: number): T[] {
if (arr.length <= n) return arr
return arr.slice(arr.length - n)
}
RingBuffer(120):只保留最近 120 个点(例如 5Hz 约 24 秒)batteryPoints.value = ...:每次更新都把可渲染数组交给图表组件resolveStampMs:把上游stamp_ms统一到 epoch(ms);解析失败就用本机接收时间,保证曲线能画出来lastT:保证时间戳单调递增,避免 ECharts time 轴因点乱序/重复导致“看起来不画线”keepLatestN:用于限制列表行数,避免无限增长
把图表渲染到模板中(示例):
<LineChartECharts :points="batteryPoints" :y-min="0" :y-max="100" label="battery(%)" />
y-min/y-max:电量可以固定 0–100,避免自动缩放导致曲线“看起来不动”
六、练习(至少 2 题)
- 给设备状态卡片增加“离线判定”:如果
Date.now() - stamp_ms > 3000,则在线显示为“离线(超时)”。 - 实现“检测数量曲线”:每次收到视觉检测 payload,把
detections.lengthpush 进 ring buffer,画出最近 60 个点的曲线。
- 实现项 1:三类数据解析 + 格式化(可扩展为你真实话题)
- 实现项 2:列表限长、曲线环形缓冲、重绘节奏控制(至少完成其中两项)
课后作业
参考与延伸
- rosbridge_suite(rosbridge 协议与实现):https://github.com/RobotWebTools/rosbridge_suite
- ROS2 CLI(topic pub/echo 等):https://docs.ros.org/en/rolling/Tutorials/Beginner-CLI-Tools.html
- WebSocket(MDN):https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
Markdown 与代码自检清单(本出稿自检)
- 标题层级连续:
#→##→###→####,未出现跳级 - 所有代码块成对闭合,且带语言标记(bash/json/ts/vue)
- subscribe JSON 字段与前置一致:
op/topic/type/id,发送前JSON.stringify - Parser 对异常输入返回
null,组件分支处理不会抛异常 - 示例 Vue SFC:
template/script/style块齐全,<script setup lang="ts">语法正确 - 实时更新策略至少包含:列表限长或环形缓冲或重绘节奏控制(本均给出)